Skip to content

Reference const-expr closures through engine ids on PHP 8.6#24

Open
nicolas-grekas wants to merge 7 commits into
mainfrom
native-constexpr-ids
Open

Reference const-expr closures through engine ids on PHP 8.6#24
nicolas-grekas wants to merge 7 commits into
mainfrom
native-constexpr-ids

Conversation

@nicolas-grekas

@nicolas-grekas nicolas-grekas commented Jun 10, 2026

Copy link
Copy Markdown
Member

The proposed PHP 8.6 "Serializable closures" engine support (implementation at nicolas-grekas/php-src#4) gives every closure declared in an attribute argument or in a parameter default value a canonical per-class id, derived from a deterministic, non-evaluating walk over the class's constant expressions, exposed as ReflectionFunction::getConstExprId() and resolved by Closure::fromConstExpr(). This PR makes deepclone use those ids, gated on PHP_VERSION_ID >= 80600, assuming the RFC lands.

  • Encoding. On PHP 8.6, deepclone_to_array() calls the exported zend_constexpr_closure_ref() and emits [class, id, line] under the existing mask marker. This replaces the per-call declaration-site scan, which had to evaluate every preceding site to count closures: on a 60-site class, encoding the last closure drops from 8.3 us to 1.2 us, and resolving it from 0.3 us scan-equivalents to a 17 ns/site pointer walk. Closures declared in class constant values and in property default values are evaluated in place by the engine and have no id; they keep the site-based 5-element form.
  • Decoding. deepclone_from_array() accepts both forms on every PHP version, discriminated by the type of element 1 (int id vs string site). Site-based payloads written on PHP 8.5 keep resolving on PHP 8.6, so caches survive the upgrade; engine-id payloads on older PHP fail with a message saying they need PHP 8.6. Resolution goes through zend_constexpr_closure_site_by_id() plus the same staleness check as before (stale payload when the declaration line moved). Crafted payloads addressing a first-class-callable site are rejected; FCCs keep the named-closure form.
  • Gating is unchanged. Closure must be allowed before anything is evaluated on the to_array side, and the payload-named class is allow-list-checked before zend_lookup_class() on the from_array side.
  • Runtime closures now surface the engine's own Closure::__serialize() refusal (Serialization of 'Closure' is not allowed) instead of NotInstantiableException, since Closure gains that method on 8.6.

The new code only compiles on PHP >= 8.6, so the current CI matrix is unaffected; the 8.6 paths were validated locally against the patched engine (all phpts pass, and the polyfill suite passes against this build with byte-identical, cross-resolvable payloads). deepclone_constexpr_closures_native.phpt covers the 8.6 behavior, deepclone_constexpr_closures_id_pre86.phpt the refusal on older PHP.

Companion polyfill implementation: symfony/polyfill#633

The site-based (5-element) const-expr closure reference is already
element-scoped and version-independent, so it needs no adaptation for the
engine's switch to element-scoped ids, and it keeps payloads interchangeable
across PHP 8.5, PHP 8.6 and the polyfill. Remove the PHP 8.6 engine-id
(3-element) encode/decode path that referenced closures by the engine's
per-class id: it coupled the wire format to that id (now a string), only
covered anonymous attribute/parameter-default closures, and could not
represent first-class-callable, class-constant or property-default sites.
deepclone_to_array() now emits the site-based reference on every version, and
dc_declaring_class() consults the engine only for a first-class callable's
declaring class (adjusted for zend_constexpr_closure_ref()'s new signature).

Also refuse a runtime closure (not declared in a constant expression) with
NotInstantiableException instead of falling through to the generic object path,
which on PHP 8.6 would call the new Closure::__serialize() and surface the
engine's "Serialization of 'Closure' is not allowed". Align the
allow_named_closures from_array message quoting with to_array and the polyfill.
One wire format on every PHP version: [class, "<site>@<rank>", line], where
the id names the declaring reflection element and the closure rank within just
that element, matching ReflectionFunction::getConstExprId(). Encoding walks one
element at a time (attributes in declaration order, nested const-expr surfaces
depth-first, then parameter defaults, then constant/property values, which
extend the rank space past what the engine addresses); on PHP 8.6 anonymous
closures short-circuit through zend_constexpr_closure_ref(), skipping all
evaluation. Decoding parses the id, tries Closure::fromConstExpr() first on
PHP 8.6 (with a line check), and falls back to evaluating only the named
element. The 5-element site form and its attrIndex dimension are removed.
… class

An absolute line invalidated every cached reference in a file when anything
above the class shifted (a new use import, another declaration). Anchoring the
line to the declaring class line keeps the reference valid across such edits:
the class and its closures move together. The declaring class line is
recomputable by the polyfill too (ReflectionClass::getStartLine), so payloads
stay byte-identical across both implementations.

Also adjust to the engine 5-argument zend_constexpr_closure_ref() signature on
PHP 8.6.
…he engine code hash

The engine now embeds a hash of the closure code in the id ("<site>@<rank>#<hash>")
and dropped the separate line field, so zend_constexpr_closure_ref() is 3-arg.
Match that: the marker-1 payload is [class, id] (no line). On PHP 8.6 the
engine id carries the hash and is verified by Closure::fromConstExpr on decode;
the ext cannot recompute the hash (the closure source is discarded at compile
time), so on 8.5, and for constant/property values on any version, the id is
hash-less and resolves positionally. Decode strips an unverifiable hash before
the value-walk, and a hash-bearing id the engine rejects is surfaced as stale
rather than healed positionally.
…ize() on PHP 8.6

The engine now exposes a const-expr closure's [class, id] declaration-site
reference through Closure::__serialize() and resolves it back through
unserialize(), replacing the reflection/reconstruction API (getConstExprClass,
getConstExprId, fromConstExpr).

Encode reads the reference from __serialize() for the positions the engine
addresses (attribute arguments and parameter defaults) and falls back to the
value-walk for constant and property default values. Decode synthesizes the
serialized form and unserializes it with the allow-list (passing allowed
classes), before the rank parse so the engine's name-keyed first-class-callable
ids resolve. A stale or unknown hash-bearing reference now surfaces the engine's
own exception instead of a ValueError.
Follow the engine's marker-1 wire format: the reference carried under
mask 1 is now [class, site, key, hash] instead of [class, "<site>@<rank>"]
with an optional "#<hash>" suffix. key is an integer rank for an anonymous
closure or the callable's name for a first-class callable; hash is the
engine's 32-bit code hash (0 for the value-walk, which cannot recompute
it, and for first-class callables).

On PHP 8.6 the reference is copied straight from Closure::__serialize()
and resolved by synthesizing the engine's own a:4:{...} payload; the
value-walk decode reads the four fields and resolves an anonymous rank
positionally, rejecting a string key it cannot place. Byte-identical to
the engine and the polyfill on 8.6, and interchangeable with 8.5.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant